You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
176 lines
5.8 KiB
176 lines
5.8 KiB
<script setup lang="ts">
|
|
import { unwrapApiBody, type ApiResponse } from '../../../../utils/http/factory'
|
|
import { formatOccurredOnDisplay, occurredOnToIsoAttr } from '../../../../utils/timeline-datetime'
|
|
import { renderSafeMarkdown } from '../../../../utils/render-markdown'
|
|
import {
|
|
buildMarkdownExportFileName,
|
|
downloadMarkdownFile,
|
|
normalizeMarkdownImageUrls,
|
|
} from '../../../../utils/markdown-export'
|
|
|
|
definePageMeta({
|
|
layout: 'public',
|
|
})
|
|
|
|
const route = useRoute()
|
|
const publicSlug = computed(() => route.params.publicSlug as string)
|
|
const shareToken = computed(() => route.params.shareToken as string)
|
|
const toast = useToast()
|
|
|
|
type Unlisted = {
|
|
kind: 'post' | 'timeline' | 'rssItem'
|
|
data: Record<string, unknown>
|
|
}
|
|
|
|
const { data, pending, error } = await useAsyncData(
|
|
() => `unlisted-${publicSlug.value}-${shareToken.value}`,
|
|
async () => {
|
|
const path = `/api/public/unlisted/${encodeURIComponent(publicSlug.value)}/${encodeURIComponent(shareToken.value)}`
|
|
const res = await $fetch<ApiResponse<Unlisted>>(path)
|
|
return unwrapApiBody(res)
|
|
},
|
|
{ watch: [publicSlug, shareToken] },
|
|
)
|
|
|
|
usePageTitle(() => {
|
|
const s = publicSlug.value
|
|
const d = data.value
|
|
if (!d) {
|
|
return ['仅链接分享', `@${s}`]
|
|
}
|
|
if (d.kind === 'post') {
|
|
const t = String(d.data.title ?? '').trim() || '文章'
|
|
return [t, '仅链接', `@${s}`]
|
|
}
|
|
if (d.kind === 'timeline') {
|
|
const t = String(d.data.title ?? '').trim() || '时光条目'
|
|
return [t, '仅链接', `@${s}`]
|
|
}
|
|
const t = String(d.data.title ?? '').trim() || '阅读条目'
|
|
return [t, '仅链接', `@${s}`]
|
|
})
|
|
|
|
const renderedUnlistedPostBody = computed(() => {
|
|
if (data.value?.kind !== 'post') {
|
|
return ''
|
|
}
|
|
return renderSafeMarkdown(String(data.value.data.bodyMarkdown ?? ''))
|
|
})
|
|
|
|
const unlistedPostPublishedAtLabel = computed(() => {
|
|
if (data.value?.kind !== 'post' || !data.value.data.publishedAt) {
|
|
return ''
|
|
}
|
|
return formatOccurredOnDisplay(data.value.data.publishedAt as string | number | Date)
|
|
})
|
|
|
|
const unlistedPostPublishedAtIso = computed(() => {
|
|
if (data.value?.kind !== 'post' || !data.value.data.publishedAt) {
|
|
return ''
|
|
}
|
|
return occurredOnToIsoAttr(data.value.data.publishedAt as string | number | Date)
|
|
})
|
|
|
|
function exportUnlistedPostMarkdown(): void {
|
|
if (data.value?.kind !== 'post') {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const origin = window.location.origin
|
|
const bodyMarkdown = String(data.value.data.bodyMarkdown ?? '')
|
|
const normalizedMarkdown = normalizeMarkdownImageUrls(bodyMarkdown, origin)
|
|
const slug = String(data.value.data.slug ?? '').trim()
|
|
const id = Number(data.value.data.id)
|
|
const filename = buildMarkdownExportFileName({
|
|
slug: slug || undefined,
|
|
id: Number.isFinite(id) ? id : undefined,
|
|
})
|
|
downloadMarkdownFile(filename, normalizedMarkdown)
|
|
toast.add({ title: '已导出 Markdown', color: 'success' })
|
|
} catch {
|
|
toast.add({ title: '导出失败,请稍后重试', color: 'error' })
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<UContainer class="py-10 space-y-6">
|
|
<div v-if="pending && !data" class="text-muted">
|
|
加载中…
|
|
</div>
|
|
<UAlert v-else-if="error && !data" color="error" title="链接无效或内容已不存在" />
|
|
<template v-else-if="data">
|
|
<UBadge color="neutral">
|
|
{{ data.kind }}
|
|
</UBadge>
|
|
<template v-if="data.kind === 'post'">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UButton
|
|
color="neutral"
|
|
variant="soft"
|
|
size="sm"
|
|
:disabled="pending"
|
|
@click="exportUnlistedPostMarkdown"
|
|
>
|
|
导出 .md
|
|
</UButton>
|
|
</div>
|
|
<p v-if="unlistedPostPublishedAtLabel" class="text-sm tabular-nums text-muted">
|
|
发布于
|
|
<time :datetime="unlistedPostPublishedAtIso" class="text-default">{{ unlistedPostPublishedAtLabel }}</time>
|
|
</p>
|
|
<h1 class="text-2xl font-semibold">
|
|
{{ data.data.title as string }}
|
|
</h1>
|
|
<PostTagsPostTagBadges
|
|
v-if="Array.isArray(data.data.tags) && (data.data.tags as string[]).length"
|
|
:tags="data.data.tags as string[]"
|
|
class="mt-2"
|
|
/>
|
|
<article
|
|
class="prose prose-neutral dark:prose-invert max-w-none prose-a:text-primary prose-img:rounded-lg prose-headings:text-highlighted prose-p:text-default prose-strong:text-highlighted markdown-body green"
|
|
v-html="renderedUnlistedPostBody"
|
|
/>
|
|
<PostComments mode="unlisted" :public-slug="publicSlug" :share-token="shareToken" />
|
|
</template>
|
|
<template v-else-if="data.kind === 'timeline'">
|
|
<h1 class="text-2xl font-semibold">
|
|
{{ data.data.title as string }}
|
|
</h1>
|
|
<p
|
|
v-if="data.data.occurredOn"
|
|
class="mt-2 text-sm tabular-nums text-muted"
|
|
>
|
|
<time :datetime="occurredOnToIsoAttr(data.data.occurredOn as string | number | Date)">
|
|
{{ formatOccurredOnDisplay(data.data.occurredOn as string | number | Date) }}
|
|
</time>
|
|
</p>
|
|
<p v-if="data.data.bodyMarkdown" class="whitespace-pre-wrap">
|
|
{{ data.data.bodyMarkdown as string }}
|
|
</p>
|
|
<a
|
|
v-if="data.data.linkUrl"
|
|
:href="data.data.linkUrl as string"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-primary"
|
|
>相关链接</a>
|
|
</template>
|
|
<template v-else>
|
|
<h1 class="text-xl font-semibold">
|
|
{{ data.data.title as string || 'RSS 条目' }}
|
|
</h1>
|
|
<p v-if="data.data.summary" class="text-muted">
|
|
{{ data.data.summary as string }}
|
|
</p>
|
|
<UButton
|
|
v-if="data.data.canonicalUrl"
|
|
:to="data.data.canonicalUrl as string"
|
|
target="_blank"
|
|
label="原文"
|
|
/>
|
|
</template>
|
|
</template>
|
|
</UContainer>
|
|
</template>
|
|
|